中斷 (Interrupt) 可以用來改變 CPU 執行程式的流程,當 CPU 在執行一個程式時,可以通過 Interrupt 讓 CPU 跑去執行其他的程式,而在完成其他程式的任務時,就會 return 到 CPU 先前執行的程式。
硬體和軟體都可以產生出 Interrupt。
在 RISC-V 中,exception 跟 Interrupt 都算是一種 trap。(不同於有些地方將 trap 以及 Interrupt 作為 Exception 的子集合)
Trap 由 user space 中的 Process 通過 Exception 所觸發,為一種同步中斷 (synchronous interrupt),trap 發生時會從 user mode 進入到 supervisor mode (進入到 kernel) 中,而在 kernel 做完一些 System call 等等操作之後再回到 user mode。
同步中斷 (synchronous interrupt)
這裡的同步中斷指的是如果一個 process 發生了 Exception,導致 trap,該 process 會先被暫停,而當 System call 完成之後,接著才會回到 process 讓他繼續執行,這稱為同步中斷。
Trap 涉及了許多細節,而這些細節對於作業系統的隔離性以及性能有十分重要的引響,許多應用程式,會因為頻繁的 System call,或是 page fault 會不斷的觸發 Trap (Trap 在 xv6 中可以解釋成處理 Interrupt 的流程,概念上類似於作業系統概論中的 ISR (Interrupt Service Routine))。
在最一開始,講到 printf()
的例子時,有提及 printf()
會使用到 write
的 System call,而過程中便會有 Traps 的產生。讓我們從 user mode 切換到 supervisor mode 中。
下面這張圖很好的表示 trap 的概念
source
以下列出與 Trap 相關的 CSRs
stvec
: 當 Trap 發生時,RISC-V 會跳進 stvec 存放的記憶體地址去處理 Traps。kernel 會將 Traps handler 的記憶體地址寫入到 stvec 中。sepc
: 當 Trap 發生時,RISC-V 會將 program counter 存放於此 (因為 program counter 的值會被 stvec
覆蓋,使其跳到 Traps handler 的記憶體地址)。sret 指令 (從 Trap 回傳) 會將 sepc
的值寫入到 program counter,回到 Trap 發生時的 program counter。kernel 可以通過寫入 sepc 來控制回到的地方 (在 xv6 啟動中可以看到類似的手法)。scause
: 使用數字來描述發生 Trap 的原因。ssctatch
: 保存其他暫存器的值。sstatus
: 在 xv6 的啟動與架構中看到了 sstatus
暫存器的結構,SIE
域控制是否啟用中斷 (Interrupt)。如果 kernel 將 SIE
設置為 0,則 I/O 設備產生的中斷將被閒置 (pending) 直到 kernel 設置 SIE
啟用中斷。SPP
域表示 Trap 是發生在 user mode 還是 supervisor mode 中。並控制 sret 要回到哪一個特權模式。stvec
(trap vector)stvec
為 CSR,包含 Handler 的記憶體地址,在 xv6 中有兩種 handler code 處理 interrupt,一種為 kernelvec (Handler supervisor mode 底下的 trap),另外一種為 uservec (Handler user mode底下的 trap)。
sstatus
上圖為 sstatus
的結構,裡面有幾個欄位控制 Interrupt。
SIE
: SIE
(Interrupts Enabled) 控制是否允許 Interrupt 發生,0 表示禁用 Interrupt,1 表示啟用 Interrupt。SPIE
: 當 Interrupt 發生時,我們需要儲存 SIE
的 bit,而這個 bit 會儲存到 SPIE
(previous Interrupt Enabled) 中。SPP
: SPP
(privilege/previous level) 記住我們是在什麼樣的特權模式底下發生了 trap,0 表示 user mode,1 表示 supervisor mode。在 Trap 發生的最一開始,CPU 處於的特權模式為 user mode,而為了執行在 kernel 中的程式碼,我們需要切換到 supervisor mode 中,在 Trap 的過程中我們需要切換模式。
satp
指向的 page table 為 user page table,user page table 無法映射到 kernel,因此在執行 kernel 中的程式碼之前,需要讓 satp
指向到 kernel page table。由於安全性考量,trap 的操作不能依賴任何來自 user mode 的資料,避免安全性被破壞,因此上述的 32 個來自 user mode 的暫存器在 supervisor mode 中只是保存這一些資料,並不會去存取他們。
當我們處於 supervisor mode 中,我們會獲得更多的權限,包含以下
satp
,決定指向的記憶體分頁或是禁用記憶體分頁的功能,而在 user mode 我們並不能完成這一些操作。PTE
中的 flag 進行更動,像是將 PTE_U
設置成 1,表示可以在 user mode 底下使用這一個虛擬記憶體到物理實體記憶體的轉換。在 supervisor mode 底下,我們仍然需要通過 page table 的方式去存取記憶體,也就是我們無法直接去存取實體物理記憶體。supervisor mode 還是受到目前 page table 的虛擬記憶體地址空間限制。
sepc
stvec
的值寫入到 program counterscause
,下圖為 scause
的結構WLRL
域會描述 Interrupt 發生的原因,如下表所示stval
用來幫助處理 trap,stval
內存放中斷處理所需要的一些訊息,例如 page fault,instruction fetch 等等等,紀錄發生目標的記憶體地址或是指令。sstatus
的 SPP
域,如果是 user mode,則用 0 表示,如果是 supervisor mode,則用 1 表示。sstatus
中 SIE
域寫入到 SIE
域中。sstatus
中 SIE
域設置為 0 ,表示禁用中斷。在以上動作完成之後,便會開始執行 trap handler。trap handler 結束後,會使用 RISC-V 中的 ret 指令回到 trap 發生的地方以及特權模式,以 sret 來說,會進行以下動作
sstatus
的 SPIE
域寫到 sstatus
的 SIE
域。sstatus
的 SPP
域中表示的特權模式,也就是進入 trap 之前的特權模式。sepc
暫存器中的值,也就是回復 program counter 的值。整個System call的動作我們可以先看作是以下操作
write()
write()
使用 ecall 指令切換到 supervisor mode 中。trampoline.s
中的 uservec。trap.c
中的 usertrap()
usertrap()
呼叫 syscall()
,syscall()
通過傳入的 System call 的編號,找到 sys_write()
sys_write()
結束後回到 syscall()
syscall()
需要完成從 supervisor mode 回復到發生 trap 時的特權模式,因此 syscall()
會通過位於 trap.c
中的 usertrapret()
完成這一件事情。usertrapret()
為 C 語言中的函式,只能處理部分回復發生 trap 的特權模式的部份工作,剩下的部分 usertrapret()
會呼叫 trampoline.s
中的 userret()
完成剩下的工作userret()
會使用一些指令回到 trap 發生時的模式,在本例為 user mode。write()
來了解 trap 的處理流程。write()
就和 printf()
一樣是一個 C 語言的函式呼叫,但實際上 write()
通過執行 ecall 指令來執行 System call。ecall 會切換到 supervisor mode中,在這個過程中,kernel 執行的第一條指令為一個使用組合語言寫的函式 uservec。也就是 trap handler 開始執行的地方。以下為 getcmd()
,其中包含了 write()
的呼叫。int
getcmd(char *buf, int nbuf)
{
write(2, "$ ", 2);
memset(buf, 0, nbuf);
gets(buf, nbuf);
if(buf[0] == 0) // EOF
return -1;
return 0;
}
在user mode底下的Shell呼叫write()
的時候,會呼叫一個關連到Shell的函式庫,這個函式庫為user/usys.pl
。
#!/usr/bin/perl -w
# Generate usys.S, the stubs for syscalls.
print "# generated by usys.pl - do not edit\n";
print "#include \"kernel/syscall.h\"\n";
sub entry {
my $name = shift;
print ".global $name\n";
print "${name}:\n";
print " li a7, SYS_${name}\n";
print " ecall\n";
print " ret\n";
}
entry("fork");
entry("exit");
entry("wait");
entry("pipe");
entry("read");
entry("write");
entry("close");
entry("kill");
entry("exec");
entry("open");
entry("mknod");
entry("unlink");
entry("fstat");
entry("link");
entry("mkdir");
entry("chdir");
entry("dup");
entry("getpid");
entry("sbrk");
entry("sleep");
entry("uptime");
可以看到li a7, SYS_${name}
,這裡的 name 為 write,因此這邊會將 SYS_wirte 載入到 a7 暫存器中,而SYS_write為巨集,表示 16。這裡為告知 kernel 要呼叫第16個 System call,也就是 SYS_write,接著可以看到下一行執行了 ecall。
從 ecall 開始會進入到 kernel space 中,也就是進入到 kernel 中,在 kernel 的工作完成後會回到 user space 中,執行 ecall 後面的 ret,最後回到 Shell。
當我們在 supervisor mode 底下執行 ecall,會觸發一個 ecall-from-s-mode-exception,接著會進入 machine mode 的中斷處理。而當我們在 user mode 底下執行 ecall,會觸發 ecall-from-u-mode-exception,進入到 supervisor mode 的中斷處理,通常這個情況會發生在 System call,也就是以下說明的情況 :
通過 gdb 來了解 ecall 的行為,我們知道 write()
會使用 ecall,而在編譯執行 xv6 時,會產生出 .asm
檔來幫助我們除錯,因此,我們可以通過檢視 sh.asm
來得知在 write()
中 ecall 的記憶體地址,在得知 ecall 的記憶體地址後,我們就能夠使用 gdb 在這個位置下斷點並且追蹤 ecall 的行為
我們可以看到 write
的記憶體地址位於 0x00000dea
,而 ecall 位於 0x00000dec
,因此我們可以在這個位置下一個斷點
接著開始執行 xv6,xv6 會在 ecall 之前停下
我們在 getcmd()
中執行了 write(2, "$ ", 2);
,我們可以把在 user mode 底下 32 個暫存器印出,得知目前暫存器的狀況
ra 0xe84 0xe84
sp 0x3e90 0x3e90
gp 0x505050505050505 0x505050505050505
tp 0x505050505050505 0x505050505050505
t0 0x505050505050505 361700864190383365
t1 0x505050505050505 361700864190383365
t2 0x505050505050505 361700864190383365
fp 0x3eb0 0x3eb0
s1 0x12e9 4841
a0 0x2 2
a1 0x3e9f 16031
a2 0x2 2
a3 0x505050505050505 361700864190383365
a4 0x505050505050505 361700864190383365
a5 0x24 36
a6 0x505050505050505 361700864190383365
a7 0x10 16
s2 0x24 36
s3 0x0 0
s4 0x25 37
s5 0x2 2
s6 0x3f50 16208
s7 0x1430 5168
s8 0x64 100
s9 0x6c 108
s10 0x78 120
s11 0x70 112
t3 0x505050505050505 361700864190383365
t4 0x505050505050505 361700864190383365
t5 0x505050505050505 361700864190383365
t6 0x505050505050505 361700864190383365
pc 0xdec 0xdec
首先由 pc (Program counter) 可以得知我們下一行要執行的指令的記憶體地址為 0xdec
,也就是 ecall 所在的記憶體位置。可以發現目前記憶體地址較小,而我們可以回憶前幾天看到的圖
可以發現到記憶體較小的區域為user space的區域,而到了後面進入到kernel space的時候,可以發現到記憶體地址會相應的增長。
a1, a2 暫存器存放我們傳入的參數,a1 裡面是記憶體地址,指向我們要寫入的字串,我們可以反參考 a1 中的記憶體地址得知我們要 write()
的內容。
而 a7 存放的是 System call 的代號,這邊存放的便是 Sys_write 的代號,16。(決定 a7 存放的是 System call 的代號是 user/usys.pl
),a0 存放 System call 的回傳值。
我們也可以試著查看 satp
的內容 (這裡查看 satp
不是在 xv6 中察看,因為我們目前是處於 user mode,我們是在 QEMU 中察看,xv6 是跑在 QEMU 上的)。這裡印出 satp
印出的是 page table 所在的物理記憶體地址。這是 process 的 page table。
我們可以通過在 QEMU 上的 console 印出 page table 中每一個entry。
(qemu) info mem
vaddr paddr size attr
---------------- ---------------- ---------------- -------
0000000000000000 0000000087f61000 0000000000001000 rwxu-a-
0000000000001000 0000000087f5e000 0000000000001000 rwxu-a-
0000000000002000 0000000087f5d000 0000000000001000 rwx----
0000000000003000 0000000087f5c000 0000000000001000 rwxu-ad
0000003fffffe000 0000000087f70000 0000000000001000 rw---ad
0000003ffffff000 0000000080007000 0000000000001000 r-x--a-
我們可以看到虛擬記憶體 0x2000
這個 page,是一張無效的 page,因為他的 U 域並沒有設置,我們可以推測這是 guardpage,防止 Shell 使用過多 stack page,而在這個 page table 中,我們可以發現最後兩條 entry 的虛擬記憶體地址十分的大,我們回憶先前看到的 process virtual address space
可以推測第一張 page 和第二張 page 分別為 trampoline 和 trapframe,而由權限等級可以判斷我們目前無法存取該區域 (U 域沒有被設置,只有被設置的情況下才能夠在 user mode 底下存取),後面我們進入到 supervisor mode 後我們便可以對這兩張 page 進行存取了。
這裡我們可以看到權限的地方可以看到 a,d,a 表示是否被使用過 (access),d 表示 PTE 是否被寫入過 (Dirty),這是我們在 Qemu 中看見的,但在先前我們知道在 xv6 中並沒有相關的實作與使用。
可以看到在 page table 中並沒有任何到 kernel page 的映射。
我們執行 ecall 指令後,我們就進入到了 supervisor mode 中,也就是我們現在位於 kernel space 中,從上面的推論中,我們可由記憶體地址判斷目前我們所在的空間,可以通過執行完 ecall 後印出 program counter 的值,知道下一個指令所在的記憶體地址,從而知道我們目前所處的 space。
關於 gdb trace ecall :
ecall 為 RISC-V 架構下的 CPU 指令,因此我們無法通過 gdb 直接使用 step 追蹤進入並查看 ecall 具體的內容,在 ecall 中會設置 stvec 暫存器的內容,而 ecall 會跳轉到 stvec 暫存器的內容,在這裡 stvec 暫存器的內容為0x3ffffff000
,也就是我們看到的,是 ecall 執行完成之後,已經完成跳轉的結果。
而我們將program counter印出來,可以發現到我們在 0x3ffffff000
的記憶體地址,而根據我們印出來的 page table 可以知道我們現在位於 trampoline 中 (也就是整個 page table 中最上面得 page),而目前的模式為 supervisor mode。而以下是trap機制中最一開始要執行的指令,也就是 trap handler 最一開始的指令,為trampoline.S
中的 uservec。
uservec:
#
# trap.c sets stvec to point here, so
# traps from user space start here,
# in supervisor mode, but with a
# user page table.
#
# save user a0 in sscratch so
# a0 can be used to get at TRAPFRAME.
csrw sscratch, a0
# each process has a separate p->trapframe memory area,
# but it's mapped to the same virtual address
# (TRAPFRAME) in every process.
li a0, TRAPFRAME
# save the user registers in TRAPFRAME
sd ra, 40(a0)
sd sp, 48(a0)
sd gp, 56(a0)
...
以上這一些指令都是在 supervisor mode 底下執行,且目前我們正處於 trampoline,也就是目前 trampoline 包含 kernel 的 trap 程式碼,而目前暫存器還是 user mode 的內容,我們接下來需要將這一些暫存器的內容儲存到某一個地方,以便之後結束 trap 之後,要回到 user mode 的時候可以成功回復狀態。
在 ecall 中並不會切換記憶體分頁,因此我們需要 trap 的處理程式碼儲存在 user page table 中,而 trap 會在 user page table 中某一個地方執行,也就是 trampoline 這一個 page 中。ecall 會跳轉到的記憶體位置是存放在stvec
暫存器中,stvec
從名子可以判斷出是一個在 supervisor mode 底下才能夠使用的暫存器。而 kernel 會設置好stvec
的內容,因此我們在執行 ecall 之後,會跳轉到 trampoline 這個 page 中。
到這裡,我們可以知道我們通過了 ecall,進入到 trampoline 中,並且從 user mode 切換到 supervisor mode 了,而整理一下,ecall 執行了以下三件事情
0xdec
(通過gdb印出暫存器內容得知)而以上為 ecall 完成的工作,下面還有很多事情需要完成 (像是儲存 32 個位於 user mode 底下的暫存器內容),而完成這一些事情依靠的就是在 System call 的動作中提及的其他需要依靠 usertrap()
和 trap.c
完成等等的操作。
我們可以發現到 ecall 實際上完成了很少的工作,原因是因為 RISC-V 想要讓程式設計者有更多的靈活性去設計軟體。前面說到 ecall 不會切換 page table,而如果我們讓 ecall 去切換 page table,對於一些沒有必要這麼做的 System call,我們很難對效能進行一些優化 ( 切換 page table 需要一定的效能開銷)。
SiFive FU540-C000 Manual v1p0
xv6-riscv
Operating System Concepts, 9/e
RISC-V xv6 Book